nestjs에서 aop 적용하기(feat. nestjs는 어떻게 데코레이터를 등록할까?)
최근 서비스에서는 Kafka 메시지 처리를 위해 정규식 기반으로 여러 토픽을 하나의 consumer에서 처리하는 구조가 사용되었습니다.
하지만 이 방식은 토픽에 대한 가시성이 떨어지고, 한 컨슈머에 문제가 생기면 전체에 영향을 줄 수 있다는 한계가 있습니다.
이에 대한 개선 방안으로, 각 토픽마다 개별 컨슈머 그룹을 구성하고,
커스텀 데코레이터(@Consume)를 통해 컨트롤러의 특정 메서드를 Kafka Consumer로 등록하는 방법을 도입해보았습니다.
이 글에서는 NestJS에서 데코레이터가 어떻게 등록되고, 어떻게 동작하는지 자세히 설명하고,
실제 Kafka Consumer 초기화 로직에 어떻게 응용할 수 있는지 살펴보겠습니다.
NestJS에서 데코레이터가 등록되는 과정
NestJS에서 데코레이터는 **마킹(Marking) → 조회(Discovery) → 등록(Registration)**의 3단계로 동작합니다.
1. 마킹 (Marking)
SetMetadata 함수
import { SetMetadata } from '@nestjs/common'; import type { ConsumeDecoratorOptions } from '#/interfaces/consume-decorator.interface'; import 'reflect-metadata'; export const CONSUME_METADATA_KEY = Symbol('KAFKA_CONSUME_METADATA'); export function Consume(options: ConsumeDecoratorOptions): MethodDecorator { return SetMetadata(CONSUME_METADATA_KEY, options); }
- NestJS는 @SetMetadata(key, value)라는 헬퍼 함수를 제공합니다. 이 함수는 내부적으로 Reflect.defineMetadata(key, value, target)를 호출하여 대상(클래스나 메서드)에 메타데이터를 저장합니다.
예를 들어, 아래와 같이 커스텀 데코레이터를 정의할 수 있습니다:
이 데코레이터가 적용된 메서드에는 CONSUME_METADATA_KEY라는 키로 options가 등록됩니다.
즉, 메서드가 "마킹"되어 후에 조회할 때 이 옵션들이 함께 나타납니다.
2. 조회 (Discovery)
NestJS는 애플리케이션이 부팅되는 동안 모든 provider(Controller, Service 등)를 순회합니다.
이때, DiscoveryService와 MetadataScanner를 사용하여 데코레이터가 등록된 대상(메서드, 클래스 등)을 찾아냅니다.
- **DiscoveryService:**모든 컨트롤러나 provider를 순회하며, 데코레이터가 적용된 인스턴스를 찾습니다.
- **MetadataScanner:**각 인스턴스의 프로토타입을 조사하여, 데코레이터가 적용된 메서드들을 수집합니다.
- **Reflector:**NestJS의 Reflector를 사용하면, Reflect.getMetadata를 래핑하여 쉽게 메타데이터를 조회할 수 있습니다.
3. 등록 (Registration)
조회 단계에서 수집된 데코레이터 메타데이터를 기반으로 실제 로직에 등록합니다.
예를 들어, Kafka Consumer 초기화 시 다음과 같이 처리할 수 있습니다:
@Injectable() export class ConsumerInitializer implements OnModuleInit { constructor( private readonly kafkaService: KafkaService, private readonly reflector: Reflector, private readonly metadataScanner: MetadataScanner, private readonly discoveryService: DiscoveryService, ) {} async onModuleInit() { const methodList: { instance: object; methodRef: (...args: unknown[]) => Promise<void>; metadata: { topic: string; groupId: string }; }[] = []; // 모든 컨트롤러를 순회 this.discoveryService.getControllers().forEach((wrapper) => { if (!wrapper.isDependencyTreeStatic() || !wrapper.instance) return; // 인스턴스의 모든 메서드 이름을 스캔 this.metadataScanner .getAllMethodNames(Object.getPrototypeOf(wrapper.instance) as object) .forEach((methodName) => { const methodRef = (wrapper.instance as Record<string, unknown>)[methodName] as (...args: unknown[]) => Promise<void>; // Reflector를 통해 데코레이터로 등록된 메타데이터를 조회 const metadata = this.reflector.get<{ topic: string; groupId: string }>(CONSUME_METADATA_KEY, methodRef); if (metadata) { methodList.push({ instance: wrapper.instance, methodRef: methodRef, metadata, }); } }); }); // 수집된 메서드에 대해 Kafka Consumer 등록await Promise.all( methodList.map(({ instance, methodRef, metadata }) => this.kafkaService.initConsumer({ groupId: metadata.groupId, topic: metadata.topic, autoCommit: true, eachMessage: methodRef.bind(instance), }), ), ); } }
각 컨트롤러를 순회하면서, 데코레이터가 등록된 메서드를 찾아낸 후, 해당 메서드를 Kafka Consumer의 콜백 함수(eachMessage)로 등록합니다.
개선된 구조: 도메인별 컨트롤러와 @Consume 데코레이터
기존에는 하나의 서비스가 정규식으로 여러 토픽을 처리하면서, 리밸런싱 등의 문제로 전체에 영향을 주었으나,
새로운 구조에서는 도메인별로 Controller를 분리하고, 각 Controller에서 아래와 같이 데코레이터를 사용합니다.
@Controller() export class AlwaysAttendanceController { constructor( private readonly alwaysAttendanceService: AlwaysAttendanceService, ) {} @Consume({ topic: TRAIN_LOG_TOPIC.GOORM_EDU_TRAIN_ALWAYS_LOG, groupId: 'gem-server-train-always-attendance', }) async consumeAlwaysAttendanceMessage( message: AlwaysAttendanceTrainLogDocument, ) { await this.alwaysAttendanceService.processAlwaysAttendanceMessage({ message }); } }
장점:
- 독립적 오프셋 관리: 각 토픽은 별도의 Consumer Group에서 관리되어, 한 토픽의 문제로 다른 토픽이 영향을 받지 않습니다.
- 맞춤형 소비 로직: 도메인마다 개별 로직을 적용할 수 있어 유연성이 증가합니다.
- 리밸런싱 영향 최소화: 각 그룹이 독립적으로 리밸런싱되므로 전체 시스템에 미치는 영향을 줄일 수 있습니다.
- 도메인 분리: 도메인별로 Controller가 분리되어, 모듈 간 의존성이 줄어들고 관리가 용이해집니다.
마무리
NestJS의 커스텀 데코레이터 등록 과정(마킹 → 조회 → 등록)을 이해하면,
복잡한 로직을 모듈화하고, 동적으로 Consumer를 등록하는 구조를 쉽게 구현할 수 있습니다.
특히, @Consume 데코레이터를 활용하여 Kafka Consumer를 도메인별로 분리함으로써,
각 토픽의 소비를 독립적으로 관리하고, 시스템의 확장성과 안정성을 높일 수 있었습니다.
토스의 https://toss.tech/article/nestjs-custom-decorator 아티클을 참고하였습니다.
댓글 (0)
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!